iT邦幫忙

2022 iThome 鐵人賽

DAY 24
3
Modern Web

一次打破 React 常見的學習門檻與觀念誤解系列 第 24

[Day 24] useEffect dependencies 的經典錯誤用法

  • 分享至 

  • xImage
  •  

這個章節讓我們更深入的探討一下 dependencies 常見的錯誤使用方式。我們在前幾篇 useEffect 的深入解析中一再強調過一個概念:useEffect 的用途是同步資料到 React elements 以外的 effect 效果,而不是生命週期 API。useEffect 的 dependencies 是一種「忽略某些不必要的執行」的效能最佳化,而不是用來控制 effect 發生在特定的 component 生命週期,或特定的商業邏輯時機。

更重要的是,dependencies 作為一種效能最佳化,它的檢查與略過 effect 的行為不是邏輯保證的,effect 有可能會在你意想不到的時候重新再次被執行,我們在接下來的篇幅中就會介紹一些可能發生的情境。

你可能會覺得如果我們將 dependencies 填上 [] 的話,effect 就永遠不會再次被執行:

import { useEffect, useState } from "react";
 
function App() {
  const [count, setCount] = useState(0);
 
  useEffect(
    // 這個 effect 在 React 18 中可能會執行兩次
    () => {
      console.log('effect start'); 
      setCount((prevCount) => prevCount + 1);
    },
    []
  );
 
  return <div>{count}</div>;
}

而實際上,這個範例中的 effect 在 React 18 中有可能會在 mount 時被執行兩次,你可以自己到這個 CodeSanbox 試試看。

這是一個 React 18 的 breaking change,不過只有在 Strict mode 以及 dev 版本的 React 中才會發生。你可能會感到詫異為什麼會有這樣的改動,這其實是為了 React 未來版本的改動所提前引入的檢查機制。我們會在下一個章節中再深入介紹相關的細節。

並且事實上,React 也有在官方文件中提到過:hooks 的 dependencies 只是一種效能最佳化而非語意邏輯保證,在未來 React 有可能會在某些時刻「忘記」dependencies 的舊值來釋放記憶體。 因此如果你的 effect 嘗試把 dependencies 作為效能最佳化以外的用途,就有可能連帶導致你的 effect 中的邏輯在你意外的情況時被再次執行。

所以這裡要鄭重強調的是:對 dependencies 誠實不只是一種推薦的 best practice,而是為了保護你的 effect 的可靠性。如果你嘗試逆流而行,在未來版本的 React 中你的 effect 很有可能真的會壞掉,導致應用程式出現非預期的行為。


錯誤用法一:ComponentDidMount in function component

你不應該嘗試使用 useEffect 搭配不誠實的 dependencies 去模擬 class component 中生命週期 API 的效果。事實上,Function component & hooks 本身並沒有直接提供任何生命週期的 API 給開發者使用。 有了資料流以及 effect 同步的設計,即使不仰賴生命週期的 API,你在絕大多數情況下也還是能夠滿足商業邏輯的開發需求。

因此在大多數情況下你都應該優先嘗試拋開生命週期 API 的思考模型,不要以「在某個 component 運作的特定時機去做特定操作來達到效果」這種指令式的思維考慮 effect 的處理,而是應該以「由資料同步到 effect,無論重複同步多少次結果都會正確」的方式來思考。

但如果你真的希望 effect 在 component 的生命週期中只會執行一次怎麼辦?我們還是可以自己用 useRef 建立一個簡單的 flag 來做到類似的效果:

import { useEffect, useState, useRef } from "react";
 
export default function App() {
  const [count, setCount] = useState(0);
  const isEffectCalledRef = useRef(false);
 
  useEffect(
    // 這個 effect 在 React 18 中可能會執行兩次
    () => {
      // 但是 if 條件式裡面的內容有 flag 擋住所以只會執行一次
      if (!isEffectCalledRef.current) {
        isEffectCalledRef.current = true;
        console.log('effect start'); 
        setCount((prevCount) => prevCount + 1);
      }
    },
    []
  );
 
  return <div>{count}</div>;
}

在加上以 useRef 實作的 flag 判斷之後,這個範例中 effect 的商業邏輯就只會執行到一次了。

useRef 除了可以用來儲存真實的 DOM element 之外,其實也很適合用來儲存與畫面沒有連動關係的跨 render 資料。這個範例中我們以 isEffectCalledRef 來儲存布林值來當作一個 flag,預設值會是 false,並且在 effect 中去做條件判斷。當 isEffectCalledRef.current 的值是 false 時,就會執行我們的商業邏輯,並且將 isEffectCalledRef.current 的值改成 true

由於 ref 的內容是可以跨 render 存取的,因此當我們第一次執行這個 effect 時 isEffectCalledRef.currentfalse 所以會順利執行我們的商業邏輯,而之後重複執行 effect 時 isEffectCalledRef.current 都已經會是 true 所以就能擋在條件式之外。如此一來,即使 effect 會被重複執行,我們真正的商業邏輯也不會被重複執行到 — 而這完全無關乎 dependencies,唯一影響 dependencies 該怎麼填的只有真實的資料依賴情況。


錯誤用法二:用 dependencies 來判斷 effect 的執行邏輯

另一種常見的 effect dependencies 的誤用就是嘗試以其作為 effect 執行與否的邏輯判斷。我們來看看一個錯誤示範:這個範例有兩種 state 資料,我們希望在每次 todos 資料有所改變時,另一個 count state 就會自動 +1:

function App() {
  const [count, setCount] = useState(0);
  const [todos, setTodos] = useState([]);
 
  useEffect(
    () => {
      setCount(prevCount => prevCount + 1);
    },
    [todos] // deps 不誠實,填寫了其實並未使用的依賴
  );

  // ...
}

在這個範例中,我們嘗試欺騙 dependencies 這個 effect 是有依賴於 todos 的,想藉此來控制判斷 todos 資料發生變化時才會執行 effect。但這樣做不是完全可靠的!在未來版本的 React 中,當你的 effect dependencies 因為效能考量而「忘記」前一次 render 的 todos 內容是什麼的時候,即使實際上你本次 render 的 todos 與上一次 render 完全是一樣的,這個 effect 還是會被執行。永遠要記得,當我們欺騙 dependencies 時,很有可能遲早會在某個時刻付出代價。

當你副作用需要商業邏輯上的條件判斷才執行時,你應該自己撰寫那些邏輯,而不是依靠本應該是效能最佳化用途的 dependencies:

function App() {
  const [count, setCount] = useState(0);
  const [todos, setTodos] = useState([]);
  const prevTodosRef = useRef();  

  useEffect(
    () => {
      if (prevTodosRef.current !== todos) {
        setCount(prevCount => prevCount + 1);
      }
    },
    [todos] // deps 誠實
  );

  useEffect(
    () => {
      // 在畫面 render 以及其它 effects 完成之後,
      // 將本次 render 的 todos 存進 prevTodosRef 中
      prevTodosRef.current = todos;
    },
    [todos]
  );
 
  // ...
}

同樣的,我們可以透過 useRef 去幫助我們記得前一次 render 的值,這樣我們就能在 effect 當中自己寫貨真價實的判斷邏輯,而不是錯誤的依靠 dependencies 來判斷資料的變化。


2024/2 更新 - 實體書平裝版本預購

在經過快要一年的努力後,本系列文的實體書版本推出了~其中新增並補充了許多鐵人賽版本中沒有的脈絡與細節,並以全彩印刷拉滿視覺上的閱讀體驗,現正熱銷中!有興趣的話歡迎參考看看,也歡迎分享給其他有接觸前端的朋友們,非常感謝大家~

《React 思維進化:一次打破常見的觀念誤解,躍升專業前端開發者》

目前首刷的軟精裝版本各大通路已經幾乎都銷售一空,接下來會再刷推出新的平裝版本:

天瓏(平裝版預購):
https://www.tenlong.com.tw/products/9786263337695

博客來(平裝版):
https://www.books.com.tw/products/0010982322

momo(平裝版):
https://www.momoshop.com.tw/goods/GoodsDetail.jsp?i_code=12528845


上一篇
[Day 23] 保持資料流 — 不要欺騙 hooks 的 dependencies(下)
下一篇
[Day 25] Reusable state — React 18 的 useEffect 在 mount 時為何會執行兩次?
系列文
一次打破 React 常見的學習門檻與觀念誤解30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

1
irissss
iT邦新手 5 級 ‧ 2023-12-27 02:29:48

Hi, Zet 也恭喜出書~這幾天系列文讀下來惠我良多,而這幾篇 useEffect 的文章讀下來仍有一點似懂非懂,尤其讀到這篇兩點常見的錯誤用法我真的左右膝蓋都中箭了QQ 在此想確認自己的理解是否正確:

  1. 所謂對 useEffect 「誠實」是指 useEffect 中會影響到 useEffect 執行的變量(state, function 等)都應該放入 dependency 中的意思嗎?

  2. 而「欺騙」是指明明使用了某變量卻沒有放到 dependency 中,或者沒使用某變量卻放到了 dependency 嗎?

  3. 承上,如果理解成 Hooks exhaustive-deps linter rule 警告要我加的我就該加(包含 useRouter 的 router 和 useDispatch 的 dispatch),而沒有要我加的我也不該加入 dependency(因為這樣是用 dependencies 來判斷 effect 的執行邏輯),這樣理解會太武斷嗎?

  4. 一時想到一個例子,假如我希望每次 pathname changes 時頁面都能回到頁面最上方(因為可能有 cache 資料所以捲軸在之前的位置),此時下面程式碼的寫法是否就觸犯了錯誤用法二?這種情況下如何調整會比較好呢?

useEffect(() => {
  if (!ref.current) return;
  ref.current.scrollTo({ top: 0 });
}, [pathname]);

不好意思劈哩啪啦問了一堆,謝謝~

Zet iT邦新手 2 級 ‧ 2023-12-27 08:36:23 檢舉

首先感謝支持~

1.誠實是指在 useEffect 的 dependencies 陣列(也就是你傳給 useEffect 的第二個參數)中,包含了 effect 函式(也就是你傳給 useEffect 的第一個參數)中所有使用到的那些「有可能在不同的 render 之間值不同」的變數,通常也就是指 props、state 以及其衍生資料或函式。同時,dependencies 陣列中不包含任何 effect 函式其實並未使用到的變數。

2.同上,所以你的第二點的理解是對的。但有些 effect 函式使用到但不會隨著 re-render 而不同的值就可以不用填,例如定義在 component function 外面的變數,它們的值永遠是固定不變的,而這種情況 linter 能直接判斷出來而不會要求你將其填進 dependencies。

3.你可以完全百分之百以 hooks linter 的提示來填寫 dependencies,甚至應該說建議這麽做會比較安全。因為雖然 linter 對於非 react 內建的 custom hooks 的回傳值無法判斷是否永遠不變(例如你提到的 react-redux 所提供的 dispatch 方法,它的值是永遠不變的,但 linter 不知道這件事所以會要求你填進 deps),然而它如果確實有在 effect 函式中被使用到的話,那麼把它填進 depedencies 是不會造成問題的,只是微幅浪費效能在每次 render 時多做一次一定會判定為相同的比較而已。

然而如果在這些情境中依賴開發者自己的判斷而不遵照 linter 的建議的話,開發者可能會在一些有可能改變的值上發生人為的誤判(某變數其實會隨著 re-render 而不同,但開發者以為不會所以就判斷不用填進 deps),而導致 re-render 時副作用的同步化效果有 bug。依賴人為去做大量且繁瑣的判斷,一定會有某天雷到自己的風險。

而多填根本沒使用到的變數到 dependencies 中則一定會被 linter 發現並警告。所以總結來說,百分之百按照 linter 的提示來填寫 dependencies,會是推薦且保證安全的做法。如果這樣填寫的結果是副作用執行的效果不如你預期,那應該做的事情是修改 effect 函式中的邏輯,而不是修改 dependencies 陣列。

4.你可以想像一下你的這段 useEffect 如果把 dependencies 參數給移除掉(不傳第二個參數),也就是不做效能優化時,會發生什麼結果。

答案是當 ref.current 有值的時候(我猜你應該是想表達裡面會存一個實際 DOM element),之後的每一次 re-render 後都會把捲軸捲到頂端。這聽起來很明顯有問題,那就代表這個 effect 函式本身沒辦法表達「每當 pathname changes 時才會做...」的條件式邏輯,原本的寫法就是常見的錯誤用法二。

至於要怎麼改也很簡單,你原本寫程式時如果想做條件式會怎麼寫?你應該自己以 if 條件式邏輯去表示「前一次 render 的 pathname 值與本次 render 的 pathname 值不同時」的判斷。所以你可以用本篇文章中有舉例的 useRef 去記憶前一次 render 時的某個值:

  const pathname = usePathname();
  const prevPathnameRef = useRef();  

  useEffect(
    () => {
      // 當前一次 render 時的 pathname 值與本次 render 的 pathname 值不同時
      if (prevPathnameRef.current !== pathname) {
        domRef.current.scrollTo({ top: 0 });
      }
    },
    [pathname] // deps 誠實
  );

  useEffect(
    () => {
      // 在畫面 render 以及其它 effects 完成之後,
      // 將本次 render 的 pathname 存進 prevPathnameRef 中
      prevPathnameRef.current = pathname;
    },
    [pathname]
  );

記得一個原則,想要確認 useEffect 的副作用處理是否安全可靠的最有效辦法,就是讓你的副作用處理即使根本沒提供 dependencies 參數以致於每次 render 後都會執行,仍然可以保持執行結果的正確性。

最後,在實體書的版本中也針對鐵人賽版本的解析有更多的脈絡補充,如果覺得對你有幫助的話也歡迎參考看看預購哦~感謝
https://www.tenlong.com.tw/products/9786263336841

irissss iT邦新手 5 級 ‧ 2023-12-27 22:22:17 檢舉

感謝超級詳細的回答,讓我對 useEffect 有更深的認識,也才知道以前那些自己覺得沒什麼問題能動就好的程式碼其實是有隱患的:

第一點使用 useRef 來確保 useEffect 只會執行一次的手法自己工作中偶爾會用到,但多數時候都是便宜行事直接 dependency 放空物件。

至於第二點錯誤自己經常犯,也沒意識到這樣會有什麼問題,感謝使用我上面的例子提供了比較好的寫法,自己不太習慣這樣的手法還要再多多練習。

書之前就預購了哈哈,祝大賣!

Zet iT邦新手 2 級 ‧ 2023-12-28 20:08:01 檢舉

感謝~

我要留言

立即登入留言